Vitest で例外処理の巻き上げ対応をしてみた
こんにちは、CX 事業本部製造ビジネステクノロジー部の若槻です。
Vitest においてインポートされたモジュールをモックできる vi.mock
ですが、この呼び出しは巻き上げ(hoist)により必ずインポート前に実行される挙動となります。
今回は、Vitest で例外処理の巻き上げ対応をしてみました。
試してみた
環境作成
Node.js & TypeScript のプロジェクトを作成します。
# package.json を作成 npm init -y # TypeScript をインストール npm install typescript --save-dev # TSConfig を作成 npx tsc --init --rootDir src --outDir lib --esModuleInterop --resolveJsonModule --lib es6,dom --module commonjs # Node.js の型定義をインストール npm install @types/node --save-dev
Vitest をインストールします。
npm i -D vitest
ソースコード
テスト対象のモジュールcreateCompany
のソースコードです。
import * as Uuid from "uuid"; import { getExternalData } from "./utils"; export interface CreatedCompany { id: string; name: string; data?: string; } export const createCompany = async (params: { name: string; }): Promise<CreatedCompany> => { const externalData = await getExternalData(); return { id: Uuid.v4(), name: params.name, data: externalData }; };
createCompany
は、一定の割合でエラーを返す(例外処理を発生させる)getExternalData
関数を呼び出します。
export const getExternalData = async (): Promise<string> => { // 一定の割合でエラーを返す if (Math.random() < 0.5) { throw new Error("external-data-error"); } return "external-data"; };
巻き上げへの対処が不要なパターン
まずは巻き上げ対応が不要なパターンを確認します。巻き上げの挙動は vi.mock
の記述がすべてインポート前に移動することのため、そもそもモックされた関数を利用するテストケースが 1 つしかない場合は、巻き上げへの対処は不要となります。
例えば次のような正常系のテストーケースのみの場合です。
import { createCompany } from "./create-company"; import { describe, test, vi, expect, beforeAll } from "vitest"; describe("createCompany", (): void => { describe("正常系", (): void => { beforeAll((): void => { vi.mock("uuid", () => { return { v4: vi.fn().mockReturnValue("e3162725-4b5b-4779-bf13-14d55d63a584"), // dummy-id }; }); vi.mock("./utils", () => { return { getExternalData: vi.fn().mockResolvedValue("external-data"), }; }); }); test("作成されたデータが取得できること", async (): Promise<void> => { const result = await createCompany({ name: "dummy-name" }); expect(result).toEqual({ id: "e3162725-4b5b-4779-bf13-14d55d63a584", name: "dummy-name", data: "external-data", }); }); }); });
テストを実行すると、正常にパスします。
$ npx vitest run --dir ./src RUN v1.2.2 /Users/wakatsuki.ryuta/projects/other/vitest-sample ✓ src/create-company.test.ts (1) ✓ createCompany (1) ✓ 正常系 (1) ✓ 作成されたデータが取得できること Test Files 1 passed (1) Tests 1 passed (1) Start at 21:50:39 Duration 240ms (transform 70ms, setup 0ms, collect 64ms, tests 1ms, environment 0ms, prepare 44ms)
巻き上げ発生によりテストが失敗する
次に、先程の正常系の後に異常系のテストケースを追加します。getExternalData
に対するモックが先行の正常系では正常なデータを返し、後続の異常系ではエラーを返すようにします。
import { createCompany } from "./create-company"; import { describe, test, vi, expect, beforeAll } from "vitest"; describe("createCompany", (): void => { describe("正常系", (): void => { beforeAll((): void => { vi.mock("uuid", () => { return { v4: vi.fn().mockReturnValue("e3162725-4b5b-4779-bf13-14d55d63a584"), // dummy-id }; }); vi.mock("./utils", () => { return { getExternalData: vi.fn().mockResolvedValue("external-data"), }; }); }); test("作成されたデータが取得できること", async (): Promise<void> => { const result = await createCompany({ name: "dummy-name" }); expect(result).toEqual({ id: "e3162725-4b5b-4779-bf13-14d55d63a584", name: "dummy-name", data: "external-data", }); }); }); describe("異常系", (): void => { beforeAll((): void => { vi.mock("uuid", () => { return { v4: vi.fn().mockReturnValue("e3162725-4b5b-4779-bf13-14d55d63a584"), // dummy-id }; }); vi.mock("./utils", () => { return { getExternalData: vi .fn() .mockRejectedValue(new Error("external-data-error")), }; }); }); test("エラーがスローされること", async (): Promise<void> => { await expect(createCompany({ name: "dummy-name" })).rejects.toThrow( "external-data-error", ); }); }); });
テストを実行すると、先程パスしたはずの正常系テストが失敗し、追加した異常系テストのみが成功するようになりました。
$ npx vitest run --dir ./src RUN v1.2.2 /Users/wakatsuki.ryuta/projects/other/vitest-sample ❯ src/create-company.test.ts (2) ❯ createCompany (2) ❯ 正常系 (1) × 作成されたデータが取得できること ✓ 異常系 (1) ✓ 作成されたデータが取得できること ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ FAIL src/create-company.test.ts > createCompany > 正常系 > 作成されたデータが取得できること Error: external-data-error ❯ src/create-company.test.ts:21:54 20| const result = await createCompany({ name: "dummy-name" }); 21| 22| expect(result).toEqual({ | ^ 23| id: "e3162725-4b5b-4779-bf13-14d55d63a584", 24| name: "dummy-name", ❯ src/create-company.ts:2:31 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ Test Files 1 failed (1) Tests 1 failed | 1 passed (2) Start at 22:09:37 Duration 265ms (transform 106ms, setup 0ms, collect 98ms, tests 5ms, environment 0ms, prepare 45ms)
これはvi.mock
が巻き上げされたことにより、後続の異常系の vi.mock
が先行の正常系の vi.mock
を上書きしてしまったためです。
巻き上げに対処する
巻き上げに対処する場合は、vi.hoisted
を利用して巻き上げを抑制します。
冒頭で各テストケースで共通で利用するモックを定義し、モックの振る舞いは各テストケースの beforeAll
内で定義します。
import { createCompany } from "./create-company"; import { describe, test, vi, expect, beforeAll } from "vitest"; const { v4Mock, getExternalDataMock } = vi.hoisted(() => { return { v4Mock: vi.fn(), getExternalDataMock: vi.fn(), }; }); vi.mock("uuid", () => { return { v4: v4Mock, }; }); vi.mock("./utils", () => { return { getExternalData: getExternalDataMock, }; }); describe("createCompany", (): void => { describe("正常系", (): void => { beforeAll((): void => { v4Mock.mockReturnValue("e3162725-4b5b-4779-bf13-14d55d63a584"); getExternalDataMock.mockResolvedValue("external-data"); }); test("作成されたデータが取得できること", async (): Promise<void> => { const result = await createCompany({ name: "dummy-name" }); expect(result).toEqual({ id: "e3162725-4b5b-4779-bf13-14d55d63a584", name: "dummy-name", data: "external-data", }); }); }); describe("異常系", (): void => { beforeAll((): void => { v4Mock.mockReturnValue("e3162725-4b5b-4779-bf13-14d55d63a584"); getExternalDataMock.mockRejectedValue(Error("external-data-error")); }); test("エラーがスローされること", async (): Promise<void> => { await expect(createCompany({ name: "dummy-name" })).rejects.toThrow( "external-data-error", ); }); }); });
テストを実行すると、いずれのテストケースも正常にパスするようになりました。
$ npx vitest run --dir ./src RUN v1.2.2 /Users/wakatsuki.ryuta/projects/other/vitest-sample ✓ src/create-company.test.ts (2) ✓ createCompany (2) ✓ 正常系 (1) ✓ 作成されたデータが取得できること ✓ 異常系 (1) ✓ 作成されたデータが取得できること Test Files 1 passed (1) Tests 2 passed (2) Start at 22:20:54 Duration 182ms (transform 32ms, setup 0ms, collect 29ms, tests 2ms, environment 0ms, prepare 55ms)
おわりに
Vitest で例外処理の巻き上げ対応をしてみました。
今回は巻き上げによりモックの振る舞いが意図通りにならない例として例外処理を取り上げましたが、正常系のテストケースを複数回記述する場合も同様となります。
巻き上げは Jest には無い挙動であり、Vitest 初心者には最初のハマりどころになる箇所だと思うので気をつけましょう。
参考
以上